iOS - 组件化实践思考

组件化的应用背景和优势在此不再赘述,下面我们将从实践的角度,讨论一下如何应用组件化的思想,下面将以我自己的理解逐步展开,抛砖引玉。

哪些内容需要组件化

在我的理解中,一个项目可以拆分为以下几种组件:

  • 基础组件;
  • 功能组件;
  • 业务组件;

下面依次来解释几种组件的定义和规则。

基础组件

  • 基本配置
    • 常量;
    • 宏定义;
  • 分类
    • 各种系统类的扩展;
  • 网络
    • 对 AFN 的封装;
    • 对 SDWebImage 的封装;
  • 工具类
    • 文件处理;
    • 设备信息;
    • 时间日期处理;

基础组件的含义就是最基础的东西,每个业务组件都有可能会使用到,基础组件需要抽取的应该是类似上面的代码,举例来说,比如我们定义了一个常量,表示接口的根路径:

1
let BASEMIRRORURL = "http://rest.mirror.xxxx.com/ios"

那么这个常量在 Home,List,Detail 都有可能会被引用,因此我们将这种最底层的,最下一层的东西归类到基础组件。

又比如分类和扩展,我们给 UIView 的扩展定义一个计算属性:

1
2
3
4
5
6
7
8
9
10
extension UIView {
var height {
set {
self.frame.size.height = newValue
}
get {
return self.frame.size.height
}
}
}

可以想到,也会有很多的业务组件会使用到这个扩展。

功能组件

  • 控件
    • 弹幕;
    • 轮播;
    • 菜单;
    • 瀑布流;
  • 功能
    • 断点续传;
    • 音视频处理;
    • CUPImage 封装;

功能组件分为可见和不可见两种,可见的是控件,不可见的是功能。功能组件的作用顾名思义,就是实现了一个功能。

业务组件

业务组件,也就是业务的具体实现了,比如一个 App 的骨架如下:

  • 首页;
  • 发现;
  • 我的;

首页下又分为这样:

  • 侧滑菜单;
  • Banner;
  • 热门;

这里的每个部分,都可以称为业务组件。

三种组件的关系

三种组件的关系

基础组件规则

基础组件和基础组件之间不应该产生依赖,比如我们使用网络请求组件,希望根路径是一个默认参数,但可以对外暴露和修改,像下面这样:

1
2
3
4
5
6
7
class NetWork {
func request(baseUrl: String = BASEMIRRORURL, path: String, param: [String:Any]) {
}
}
NetWork.request(path: "/g/login.server", param: param)

这时,NetWork 就依赖了 常量 这个基础组件,我们如果使用 NetWork 基础组件,还需要导入 常量 这个基础组件,这是不应该的。

但为了代码的简洁性,这样的封装又是必要的,那么应该怎么做呢?这个问题我们下面会讲到。

功能组件规则

功能组件和基础组件之间不应该产生依赖,比如我们做轮播图,会用到 UIView 的扩展常量 ,像下面这样:

1
imageView.width = SCREENWIDTH

其中 .widthSCREENWIDTH ,都在基础组件中,但基础组件中不仅仅是这些东西,如果依赖了基础组件,就需要导入基础组件中其他无用的代码,而且其他人使用轮播图组件,也需要导入基础组件。

因此,在功能组件中,不建议依赖基础组件,上面的代码应该改成这样:

1
imageView.frame.size.width = UIScreen.main.bounds.size.width

或者直接复制代码,将需要的基础组件的功能,复制到功能组件当中。

同基础组件一样,功能组件和功能组件也不应该产生依赖,道理是一样的,我们使用一个功能,不应该将另一个功能也导入进来。

业务组件规则

基础组件和功能组件都是为业务服务的,因此业务组件可以依赖于基础组件和功能组件,快速的实现业务,但是业务组件和业务组件之间不应该产生依赖。

比如这样一条业务线,我们要求 发现 这个业务组件,点击一条视频,跳转到 视频播放器

1
2
3
4
func pushToPlayerVC(model: VideoModel) {
let vc = PlayerVC(videoModel: model)
navigationVC.push(vc)
}

这时 发现 就对 视频播放器产生了依赖,如果将 发现 进行组件化进行剥离,能行吗?不行。

其实这个问题和网络请求使用默认参数封装一样,是组件与组件之间的通讯问题,当然,这个问题我们下面会讲到,现在再提一下是为了一会儿往下写的时候忘了填坑 …

每个组件存在的形式

  • 组件内部;
  • 组件外部;
  • 组件测试;

组件内部

组件的内部应该使用设计模式划分文件夹的结构,例如 MVVM 结构:

1
2
3
4
---- PlayerView
-- View
-- Model
-- ViewModel

组件外部

组件的外部应该是一个远程私有 pod 库,使用 CocoaPods 进行管理。

组件测试

单独的测试工程。

怎样集成各个组件

组件集成

组件的集成应该像上面的图一样,基础组件和功能组件互不依赖,制作远程 pod 私有库,业务组件依赖于这些 pod 私有库开发,同样制作成远程 pod 私有库,壳工程依赖于 CocoaPods 管理这些私有库,完成整个项目。

当然还有另外的方式,比如将壳工程作为主工程,组件创建为子工程,这方式的缺点是子工程可以修改,缺少约束性,目录结构也比较凌乱。

还有将组件制作为 FrameWork,壳工程中导入一个个 FrameWork 库,这种方式个人感觉比上一种好一些,但是在物理上,组件和壳还是没能做到分离。

因此,我个人还是更倾向于 pod 库的形式。

组件之间的通讯

  • 对外公开 API 接口;
  • 通过中间件的中转;

上面我们有两个遗留的问题,归纳为组件之间的通讯问题,下面就通过这两个问题,讨论一下组件之间的通讯。

网络请求默认参数

下面的思路就是暴露出 baseUrl 参数,通过中间件 NetWorkMWNetWork常量 两个基础组件组合,完成默认参数网络请求的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 基础组件 - 常量
let BASEMIRRORURL = "http://rest.mirror.xxxx.com/ios"
// 基础组件 - 网络请求
class NetWork {
func request(baseUrl: String, path: String, param: [String:Any]) {
}
}
//壳工程 - 网络请求中间件
class NetWorkMW {
func request(baseUrl: String = BASEMIRRORURL, path: String, param: [String:Any]) {
NetWork.request(baseUrl: baseUrl, path: path, param: param)
}
}
NetWorkMW.request(path: "/g/login.server", param: param)

发现跳转视频播放

这个思路是使用代理,对外暴露点击事件,通过中间件,导入 视频播放 业务组件,topVC 基础组件,完成向 视频播放 的跳转:

1
2
3
4
5
6
7
8
9
10
// 业务组件 - 发现
func pushToPlayerVC(model: VideoModel) {
delegate?.pushToPlayerVC?(videoModel: model)
}
// 中间件 - 发现
func pushToPlayerVC(model: VideoModel) {
let vc = PlayerVC(videoModel: model)
topVC.navigationVC.push(vc)
}

以上实际上是怎么样把多个组件组合使用起来,这种组合是确定的,还有一些是不确定的,例如有一个组件的状态改变了,我要让其他组件知道我的变化,但是我不知道都要告诉谁,怎么办?

眼珠一转,对外暴露状态变化,中间件在变化时发送通知。但是同时我想附带一个模型过去,通知的接收方怎样正确的使用这个模型呢?如果要使用模型,势必要和发送通知的业务组件产生耦合,怎么办?

以后再办,先埋个坑,这些场景我们会在以后再讲到。

组件分离的难点

组件分离的重点和难点也就是解耦,比如我们现在负责一个项目,其中的一个业务或者功能,希望实现组件化,但是它依赖于项目中的其他公共功能,该如何处理呢?这里提供两种思路:

  1. 拷代码,简单粗暴,摆脱依赖,对于一些不重要的工具方法,可以直接拷贝到内部来使用;
  2. 把组件依赖的代码先做一个 pod 库,然后依赖这个 pod 库;

上面讲到的是代码方面的依赖,还有一种情况是功能方面的依赖,比如我们有一个菜单,这个菜单涉及到网络图片的加载,那么怎样将这个菜单进行组件化呢?

  1. 使用 Block 或者代理,将网络图片加载这部分的职责交给外部控制;

举例来说,像下面这样:

1
2
// 业务组件 - 菜单
self.imageView.sd_setImage(with: url, completed: completed)

那么如果现在将它组件化,这个组件就要依赖于 SDWebImage,我们应该修改成这样:

1
2
3
4
5
6
7
// 业务组件 - 菜单
setImage?(for: imageView, completed: ImageLoadCompletedBlock)
// 中间件 - 菜单
menu.setImage = { (imageView, completed) in
imageView.sd_setImage(with: url, completed: completed)
}

现在菜单就摆脱了对 SDWebImage 的依赖。

附加问题

以上的环节掌握了,应该可以尝试简单的组件化了,但是问题没完,还有哪些呢?

库的升级维护

随着项目的迭代,你负责的库升级了,其他的小伙伴们还在用上个版本的库,怎么办?

各种路径资源问题

我们在自己的库里使用了 imageNamedmainBundle,但是小伙伴把我们的库拖过去后,这些路径和我们不是一个路径,Assets.xcassets 跟我们也不是同一个 Assets.xcassets,怎么办?

这些问题你可以从这篇文章找到答案:你真的会用 CocoaPods 吗?